Skip to content

Fix #1519: Port compiler Plugin from scalac #3438

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 27 commits into from
Mar 26, 2018

Conversation

liufengyun
Copy link
Contributor

@liufengyun liufengyun commented Nov 4, 2017

Fix #1519: This PR ports compiler plugin from Scalac and adds the test infrastructure.

Scalac also supports Typer plugins, which is contentious, so it's not addressed in this PR. Current design allows easy extension to support Typer plugins if we want to do that later.

Changes from Scalac plugin:

  • Removed the concept PIuginComponent
  • Research plugin: research plugins decides where to inject their own phases

TODOs:

  • basic plugin works
  • test infrastructure
  • ordering constraints of plugins
  • unit test ordering algorithm
  • SBT plugin test

def test(tpe: String): Boolean =
(sym.owner eq ctx.requiredClass(tpe.toTermName)) && sym.name.show == "/"

test("scala.Int") || test("scala.Long") || test("scala.Short") || test("scala.FLoat") || test("scala.Double")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: FLoat -> Float


private def isNumericDivide(sym: Symbol)(implicit ctx: Context): Boolean = {
def test(tpe: String): Boolean =
(sym.owner eq ctx.requiredClass(tpe.toTermName)) && sym.name.show == "/"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to do sym.name == nme.DIV

@@ -0,0 +1,4 @@
<plugin>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should check that sbt's support for compiler plugin still works and adapt it in sbt-dotty if it doesn't: http://www.scala-sbt.org/1.x/docs/Compiler-Plugins.html We can do that by adding scripted tests in https://github.com/lampepfl/dotty/tree/master/sbt-dotty/sbt-test/sbt-dotty

val phaseName = name

override def init(phases: List[List[Phase]])(implicit ctx: Context): List[List[Phase]] = {
val (before, after) = phases.span(ps => !ps.exists(_.phaseName == "pickler"))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to not rely on strings to identify phase names and use class types instead, similar to what we do with Phase.replace in https://github.com/lampepfl/dotty/blob/master/compiler/src/dotty/tools/repl/ReplCompiler.scala#L40

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strings compose with phases coming from other compiler plugins. Class types do not.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sjrd Good point. But can't we use Class.forName in that case?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If all compiler plugins are loaded in the same ClassLoader, then yes. But is that the case and is it desirable?

@sjrd
Copy link
Member

sjrd commented Nov 4, 2017

The interface with init is both too powerful and not enough:

  override def init(phases: List[List[Phase]])(implicit ctx: Context): List[List[Phase]] = {
    val (before, after) = phases.span(ps => !ps.exists(_.phaseName == "pickler"))
    before ++ (List(this) :: after)
  }

First, it is too powerful, because it allows to reorder core phases, or phases of other plugins.

Also, it allows to insert micro-phases in existing macro-phases, which is extremely sensitive. I do not believe anyone outside of the core compiler team can write a correct micro-phase that would interact badly with the rest of its macro phase. IMO compiler plugins should only be able to insert macro phases (themselves possibly consisting of multiple micro-phases, all provided by the same plugin).

Then, it is not powerful enough, because it does not allow to correctly express dependencies between compiler plugins. With the scalac compiler plugin interface, I can write a compiler plugin B that knows about another compiler plugin A, and correctly declares its dependencies wrt. that one. With this interface, this is not possible. Let me illustrate this with a very concrete (and existing!) example:

The Scala.js JUnit compiler plugin has a phase junit-inject which needs to run between mixin and jscode (the latter being the code generation phase of the Scala.js compiler plugin. In scalac, this is easy to specify declaratively in ScalaJSJUnitPlugin. The core ScalaJSPlugin doesn't need to know about the JUnit compiler plugin. With the proposed interface for dotty, this dependency cannot be enforced without the core Scala.js plugin know about the JUnit compiler plugin. Indeed, even if the JUnit compiler plugin carefully manipulates the phases to insert itself between mixin (or whatever other phase is accurate for dotty in this case) and jscode, this only works if the init method of the JUnit plugin is called after that of the core plugin. Indeed, in the other direction, the JUnit plugin can only insert itself between mixin and delambdafy (continuing my earlier scalac-based example). Then when the core plugin's init method kicks in, since it does not know about junit-inject, it will insert itself either before junit-inject or after it. In the former case, things get broken.

This means that obtaining correct inter-plugin dependencies depends on the order in which the init methods are called. This is really bad, as this order is likely to indirectly derive from ivy resolution order.

For these reasons, I disapprove of this new interface. I strongly suggest an interface that is closer to scalac, specifying a list of afterPhases and a list of beforePhases (probably dropping rightAfterPhase because that one does not compose). And I would only allow plugins to insert macro phases. The design could provide for a possible future extension point that would allow expert compiler plugin authors to inject micro phases.

@liufengyun
Copy link
Contributor Author

@sjrd Thanks a lot for sharing your experience, it's a valuable feedback 👍

We are in agreement that the new design is more powerful. As compiler plugins are for very advanced users & researchers, we may assume they use the power wisely. I hope you can also agree that the protocol/API is simpler, and the implementation is easy. In general, the usability for plugin writers is better.

For the particular problem you raised, is it possible for the junit-inject plugin to act as a facade and also add ScalaJS phases to the phase plan in the init of junit-inject?

The problem is in general about composability of plugins, which we know is very tricky: compiler plugins just don't compose easily. The Scalac approach solves some of the problems by allowing plugins to specify ordering constraints.

But the solution in Scalac is still unsatisfactory. How can a plugin knows in advance its relative order to all other plugins, given that

  • it's impractical for a plugin developer to test the ordering wrt. all existing plugins
  • new plugins continue to appear
  • logically, if PlugA < PlugB, should the ordering constraint be in PlugA or PlugB, or both?

The new approach instead shifts all the responsibility to programmers. This seems to be a more coherent approach to me. In practice, a few plugins will be regularly used together to solve a particular problem. Due to the frequency of the combined usage, frictions among the plugins will be detected and reduced, compatibility will improve and the relative ordering will be known. Then, it's simple to just create an orchestrate/facade plugin to compose the phase plan if the ordering is sensitive.

In the new approach, creating a facade compiler plugin is easy, just a few lines of code will do. As compiler plugins usually work with sbt, the compiler plugin facade can be authored in SBT(Build.scala) to orchestrate the phase plan, or come from some pre-defined SBT plugin. We do need to investigate to see if this approach works well with SBT and user experience is not impacted.

From the viewpoint of design, if practical composability problems can solved with the compiler taking ZERO responsibility, then it doesn't make sense for the compiler to take just PARTIAL responsibility. I assume It's always better for the boundary of responsibilities to be clear.

@@ -87,7 +89,7 @@ class PluginsTest {
def orderingTwoPlugins1 = {
object M1 extends TestPhase {
override val runsAfter = Set(classOf[P3d])
override val runsBefore = Set(M2.getClass, classOf[P7], classOf[P8])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened #3495 to keep track of this.

@smarter
Copy link
Member

smarter commented Jan 5, 2018

Something to keep in mind: the dynamic classloading of compiler plugins seems to have a significant performance cost, which could perhaps be avoided by better caching of classloaders, see scala/scala-dev#458

@OlivierBlanvillain
Copy link
Contributor

@liufengyun what's is the status of this PR? If it needs a review please assign it to a reviewer :)

@liufengyun
Copy link
Contributor Author

@OlivierBlanvillain This is up for review. I think @sjrd has the best experience on plugins, but it's good to get more reviews.

@smarter
Copy link
Member

smarter commented Feb 1, 2018

@nicolasstucki nicolasstucki assigned smarter and unassigned nicolasstucki 7 days ago

Unfortunately I won't have time to review this myself in the near future.

@smarter smarter removed their assignment Feb 1, 2018
@liufengyun liufengyun assigned nicolasstucki and unassigned sjrd Mar 9, 2018
Copy link
Member

@sjrd sjrd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I finally got around to reviewing this. Thanks @smarter for the ping.

* Research plugin receives a phase plan and return a new phase plan, while
* non-research plugin returns a list of phases to be inserted.
*/
def research: Boolean = false
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A boolean flag, and then two methods among which only one should be overridden, can be advantageously replaced by:

  • Making Plugin sealed, with neither of those three methods
  • Have 2 subtraits ResearchPlugin and StandardPlugin, each only with the one relevant abstract method.

/** ...
*
* @author Lex Spoon
* @version 1.0, 2007-5-21
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this relevant?

*/
object Plugin {

private val PluginXML = "scalac-plugin.xml"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be dotc-plugin.xml. Plugins for one or the other compiler are definitely not interoperable, so they should be identified by a different file.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC, the "name" in that xml is unused. Maybe it's possible to ditch the xml. See Adriaan's work to avoid dependency on scala-xml at some point. It could be a properties file, or just a file containing a class name, radical simplicity. More like java's service loader. Seems a shame to drag xml config into scala 3.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, only the class name is actually used. Maybe we can assume a text file named plugins with each line corresponding to a class name.

Success[AnyClass](loader loadClass classname)
} catch {
case NonFatal(e) =>
Failure(new PluginLoadException(classname, s"Error: unable to load class: $classname"))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't e.toString be included in the PluginLoadException? Currently this completely discards all the relevant information that could be used to debug an issue.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oddly, I recall inheriting that when I touched the code. Generally e.getMessage == classname for ClassNotFoundException, but this code could be more nuanced in avoiding repeating the name, especially for other throwables or if there's a cause. I must have added the next line in this vein. You're more likely to hit NoClassDef with multiple class loaders?

}

private def loadDescriptionFromFile(f: Path): Try[PluginDescription] =
Try(PluginDescription.fromXML(new java.io.FileInputStream(f.jpath.toFile)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From an API point of view, I think it would be more consistent to call this method loadDescriptionFromDirectory, and have it take the directory containing the PluginXML file (rather than the PluginXML file). This is more consistent wrt. loadDescriptionFromJar, because on a classpath, a jar ~= a directory.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not API, but it would be more consistent. Both methods could be local.

import scala.util.{ Try, Success, Failure }

trait PluginPhase extends MiniPhase {
def runsBefore: Set[Class[_ <: Phase]] = Set.empty
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For inter-plugin dependencies, it will be impossible for a plugin A to get hold of the Class[_] value for a plugin B coming from a different jar (hence in a different ClassLoader, so not even reflectively available).

We need to use names here (also for runsAfter). Names are usually not as good as token identities, but here they offer an essential part of the expressiveness.

def test(tpe: String): Boolean =
(sym.owner eq ctx.requiredClass(tpe.toTermName)) && sym.name.show == "/"

test("scala.Int") || test("scala.Long") || test("scala.Short") || test("scala.FLoat") || test("scala.Double")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo: FLoat -> Float

@@ -0,0 +1 @@
sbt.version=0.13.15
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0.13.17?

def test(tpe: String): Boolean =
(sym.owner eq ctx.requiredClass(tpe.toTermName)) && sym.name.show == "/"

test("scala.Int") || test("scala.Long") || test("scala.Short") || test("scala.FLoat") || test("scala.Double")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same typo on FLoat

def test(tpe: String): Boolean =
(sym.owner eq ctx.requiredClass(tpe.toTermName)) && sym.name.show == "/"

test("scala.Int") || test("scala.Long") || test("scala.Short") || test("scala.FLoat") || test("scala.Double")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And again the same typo.

@liufengyun
Copy link
Contributor Author

Thanks a lot for the review @sjrd , I'll address them this weekend.

Plugin.load(pd.classname, loader)
case Failure(e) =>
Failure(e)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try.flatMap.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original use of map is a good fit here. We are changing Success to Failure in the first two cases, Try.flatMap will not help here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try.flatMap allows changing Success to Failure:

(fromPaths ::: fromDirs) flatMap {
  case (pd, loader) if seen(pd.classname) => Failure(new PluginLoadException(pd.name, s"Ignoring duplicate plugin ${pd.name} (${pd.classname})"))
  case (pd, loader) if ignoring contains pd.name =>
    Failure(new PluginLoadException(pd.name, s"Disabling plugin ${pd.name}"))
  case (pd, loader) =>
    seen += pd.classname
    Plugin.load(pd.classname, loader)
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @Blaisorblade , sure, that's the whole purpose of flatMap/bind, a temporary short-circuit in my brain ;-)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now to backport to scala 2.

@smarter
Copy link
Member

smarter commented Mar 20, 2018

Since this PR ports existing code, it would be good to add a note to https://github.com/lampepfl/dotty/blob/master/AUTHORS.md for proper attribution.

Quote @sjrd:

For inter-plugin dependencies, it will be impossible for a plugin A to
get hold of the `Class[_]` value for a plugin B coming from a different
jar (hence in a different `ClassLoader`, so not even reflectively available).

scala#3438 (comment)
Copy link
Member

@sjrd sjrd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice :) Looks good to me :)

@allanrenucci allanrenucci merged commit db5ca52 into scala:master Mar 26, 2018
@allanrenucci allanrenucci deleted the plugin branch March 26, 2018 14:30
@som-snytt
Copy link
Contributor

I would have smiley-faced sjrd's approval comment if github provided that function.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants